Skip to content

Ship account-aware v0-style Studio flow - #23

Merged
svg8bit merged 5 commits into
mainfrom
codex/drops-v0-flow-auth-persistence
Jul 31, 2026
Merged

Ship account-aware v0-style Studio flow#23
svg8bit merged 5 commits into
mainfrom
codex/drops-v0-flow-auth-persistence

Conversation

@svg8bit

@svg8bit svg8bit commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • turn prompt and recipe builds into persisted Project V2 workspaces that open directly in the unified Director flow
  • add Google OIDC, encrypted account connection persistence, safe project deletion, and remembered BYOK routing
  • harden OAuth return paths, real builder phase reporting, responsive source workspace, and approved visual baselines

Verification

  • npm run lint
  • npm run typecheck
  • npm run build:vercel
  • 770 unit tests passed, 2 skipped
  • 162 Playwright tests passed, 42 skipped
  • Storybook build and 47 component tests passed
  • 42 Storybook visual tests passed
  • Lighthouse budgets passed
  • targeted post-CodeRabbit OAuth/storage tests passed
  • targeted v0 flow and approved visual baselines passed

Release

Ready for main and production after required checks pass.

Summary by CodeRabbit

  • New Features
    • Added Google sign-in with account profiles and secure sessions.
    • Added account-based connection management for OpenRouter, Google, Telegram, and DropsTab.
    • Added optional encrypted credential restoration for signed-in accounts; guest connections remain session-only.
    • Added individual and bulk project deletion with cloud synchronization.
    • Added build progress updates, recipe-based builds, and improved Director workspace navigation.
  • UI Improvements
    • Added account controls, connection statuses, responsive layouts, project actions, and enhanced project cards.
  • Documentation & Tests
    • Updated setup and security documentation and expanded automated coverage.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
drops-studio Ready Ready Preview Jul 31, 2026 8:02pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@svg8bit, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 889da639-a9b8-4af5-831a-a1965bb083bc

📥 Commits

Reviewing files that changed from the base of the PR and between 2431713 and 71a039c.

⛔ Files ignored due to path filters (6)
  • e2e/visual/home.spec.ts-snapshots/home-builder-chromium-1024-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/home-builder-chromium-1440-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/home-builder-chromium-390-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-390-linux.png is excluded by !**/*.png
📒 Files selected for processing (2)
  • e2e/contracts/member-project-cloud.spec.ts
  • storybook-e2e/visual.spec.ts
📝 Walkthrough

Walkthrough

The PR adds Google OIDC sign-in, encrypted account-scoped connection storage, remembered credentials, project deletion synchronization, account controls, and Director workspace progress reporting.

Changes

Account-backed Studio workflows

Layer / File(s) Summary
Identity, encryption, and account-state foundations
.env.example, lib/google-oidc.ts, lib/studio-account-state.ts, db/studio-account-state.ts, lib/safe-return-to.ts, lib/studio-account-profile.ts, lib/access-tier.ts
Adds Google OIDC transactions, signed sessions, safe return paths, AES-256-GCM connection envelopes, validated account state, and profile helpers.
Account APIs and remembered credentials
app/api/auth/..., app/api/account/..., app/api/builder/..., app/api/dropstab/route.ts, app/api/telegram/account/...
Adds Google authentication callbacks, account connection APIs, persisted OpenRouter credentials, and remembered provider and Telegram credential restoration.
Project deletion and Connections Hub UI
lib/project-store.ts, lib/member-project-sync-client.ts, components/drops-studio.tsx, components/drops-studio-dialogs.tsx, app/styles/drops-studio.*
Adds lock-aware local deletion, cloud deletion synchronization, rollback handling, account controls, encrypted connection messaging, and project deletion actions.
Director workspace and builder progress
components/project-studio.tsx, components/project-v2-studio-surface.tsx, app/styles/project-studio.*, components/project-workspace-dialog.tsx, components/telegram-channel-wizard.tsx, e2e/contracts/v0-studio-flow.spec.ts
Adds account-aware provider loading, Connections Hub return navigation, Director autobuild routing, builder lifecycle conversation events, responsive workspace updates, and encrypted Telegram restore messaging.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: shipping an account-aware v0-style Studio flow.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/drops-v0-flow-auth-persistence

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 33714797f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1369 to +1373
await fetch("/api/account/connections?provider=openrouter", {
method: "DELETE",
credentials: "same-origin",
headers: { accept: "application/json" },
}).catch(() => undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check vault deletion before confirming OpenRouter disconnect

When a signed-in user has a remembered OpenRouter key and the DELETE request returns 503/non-2xx or encounters a network failure, this catch discards the result and the function clears local state and reports “OpenRouter disconnected.” The encrypted credential therefore remains in the account vault and will be restored on the next load; only clear the connection and confirm disconnection after an ok response.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

Comment on lines +196 to +200
void fetch("/api/account/connections?provider=telegram", {
method: "DELETE",
credentials: "same-origin",
headers: { accept: "application/json" },
}).catch(() => undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Await Telegram vault deletion before clearing the session

For a signed-in user whose Telegram token was saved in the account vault, any failed or non-2xx DELETE is ignored while the wizard immediately clears its local state and presents the phone sign-in flow. The supposedly disconnected credential remains server-side and the next wizard mount can silently restore it, so this action must await and validate deletion before changing the connected state.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

Comment on lines 1461 to +1464
setToast(
"Custom API configured for this tab. It will be called directly by your browser when you plan.",
remembered
? "Custom API verified for this tab and encrypted in your Studio account vault."
: "Custom API configured for this tab. Sign in to remember it across sessions.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify custom providers before marking them connected

When a signed-in user enters an unreachable endpoint, invalid key, or nonexistent model, this path performs only local syntax checks and stores the credential through /api/account/connections; neither that endpoint nor this branch contacts the provider. A successful vault write is therefore reported as “Custom API verified” and later restored as a connected provider despite having no provider evidence, so test the endpoint before persisting/claiming verification or label it as unverified configuration.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

Comment on lines +1954 to +1958
if (projectSyncAvailable) {
await deleteProjectV2FromCloud(project.id);
}
if (memberConnected) {
await deleteMemberProjectFromCloud(project.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Roll back V2 deletion when legacy cloud cleanup fails

For a signed-in project with both sync paths enabled, deleteProjectV2FromCloud permanently destroys the Sandbox, release receipts, and V2 snapshot before deleteMemberProjectFromCloud runs. If the latter then returns a conflict or transient error, the catch restores only the browser record, leaving the operation reported as failed while the V2 cloud state has already been deleted; restore that remote state as part of rollback or sequence the operation through an atomic server-side deletion.

AGENTS.md reference: AGENTS.md:L76-L76

Useful? React with 👍 / 👎.

Comment on lines 1421 to +1423
const activeProvider = useMemo(() => {
if (!project) return "free" as ProjectProvider;
return (window.sessionStorage?.getItem("drops-studio:active-brain") ||
project.spec.brain.provider ||
"free") as ProjectProvider;
}, [project]);
if (accountBrain) return accountBrain;
return project?.spec.brain.provider || "free";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor an explicit Free Auto selection in Project Studio

When drops-studio:active-brain is explicitly set to free, it is excluded from providers, so accountBrain remains null, while the later if (current) return prevents account hydration from choosing anything else. activeProvider then falls back to the project’s historical provider, causing a Builder run to use a paid remembered provider or fail for missing credentials even though the user selected Free Auto; preserve free as a valid session selection.

AGENTS.md reference: AGENTS.md:L85-L85

Useful? React with 👍 / 👎.

Comment on lines +39 to +44
if (account && "accountToken" in result && typeof result.accountToken === "string") {
await saveStudioConnection(account.identity, {
provider: "telegram",
credential: result.accountToken,
label: "Telegram account session",
}).catch(() => undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface Telegram account-vault save failures

For a signed-in Studio account, a missing vault key, storage outage, or conflicting account-state write is swallowed here and the route returns the normal successful Telegram sign-in payload. The wizard explicitly promises signed-in users encrypted cross-session restore, but after this path the session exists only in the current tab and disappears on reload; include the vault-save result in the response and present the connection as session-only when persistence fails.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

Comment on lines +343 to +347
<strong>{memberConnected ? "Encrypted account vault." : "Session-only storage."}</strong>{" "}
The key is never written to project files, logs, ZIPs or checkpoints.
{memberConnected
? " After verification it is encrypted server-side and can be removed here."
: " Sign in to remember it across sessions."}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expose vault removal for every remembered provider

For signed-in users with remembered DropsTab, OpenAI, Anthropic, Kimi, or custom credentials, this text promises that the key “can be removed here,” but the dialog exposes a delete action only for OpenRouter; the other action buttons merely re-test or overwrite the connection and require another key. These users therefore have no UI path to revoke credentials stored by this same screen, so provide a confirmed DELETE control for every persisted provider.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

Comment on lines +595 to +599
onAgentEvent?.({
phase: "sandbox",
status: "done",
message: "Project files are running inside the isolated Node 24 Sandbox.",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark the Sandbox done only after successful runtime evidence

Whenever /api/builder/agent returns any result, this records “Project files are running inside the isolated Node 24 Sandbox” before checking its status or release gate. A 422 blocked result can represent an unconfigured fallback, Sandbox startup failure, or gate failure, yet the persisted Director history retains this phase as “Verified” even after verification is marked blocked; emit the completed Sandbox event only when the result contains successful runtime evidence.

AGENTS.md reference: AGENTS.md:L83-L85

Useful? React with 👍 / 👎.

Comment on lines +102 to +105
export async function DELETE(request: NextRequest) {
const actor = account(request);
if (!actor) return response({ error: "Sign in to change remembered connections." }, 401);
if (!sameOrigin(request)) return response({ error: "Cross-origin connection storage rejected." }, 403);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rate-limit account connection deletions

Unlike PUT, every authenticated same-origin DELETE reaches deleteStudioConnection, which reads and rewrites the private Blob state and increments its revision even when that provider is already absent. A buggy or malicious signed-in client can therefore generate unbounded storage writes and retry work through this endpoint; apply the same fail-closed connection-write quota before performing the mutation.

AGENTS.md reference: AGENTS.md:L109-L109

Useful? React with 👍 / 👎.

Comment on lines +540 to +543
window.sessionStorage.setItem(
`drops-studio:${connection.provider}:model`,
connection.model,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist model changes made after connecting

The vault stores the model selected during the initial connection, but subsequent picker changes update only sessionStorage; there is no account-state update for the new selection. On the next browser session this hydration unconditionally writes the stale vault model back into sessionStorage, silently reverting the user’s choice and sending later remembered-provider requests to the old model, so persist model selection changes or stop restoring this stale metadata.

AGENTS.md reference: AGENTS.md:L85-L85

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
components/telegram-channel-wizard.tsx (1)

195-210: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Handle the DELETE failure before clearing local Telegram state.

disconnect fires DELETE /api/account/connections?provider=telegram and immediately clears local session state regardless of the outcome (.catch(() => undefined) swallows any failure). If the account-scoped deletion fails silently, the encrypted Telegram connection stays stored server-side while the UI shows "disconnected." On the next load, the always-on status check (lines 92-113) can restore that stale connection from the signed-in account, silently undoing the user's disconnect action with no error shown.

Check the response status and surface an error (and keep the connected UI state) when the deletion fails, instead of unconditionally clearing local state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/telegram-channel-wizard.tsx` around lines 195 - 210, Update
disconnect so it awaits the DELETE request, validates the response status, and
only clears session and Telegram UI state after a successful deletion. On
request or non-success response failure, surface an error to the user and
preserve the connected state instead of swallowing the failure.
🧹 Nitpick comments (14)
lib/studio-account-profile.ts (1)

5-14: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Make the display-name truncation and the initial locale-independent.

Line 14 calls toLocaleUpperCase() with no locale argument, so the result depends on the ambient default locale of the runtime. The server runtime locale and the browser locale can differ, and drops-studio.tsx renders this initial in markup, so the values can disagree during hydration. Line 6 also truncates by UTF-16 code unit, which can split a surrogate pair at position 160.

♻️ Proposed change
-  const normalized = value?.trim().replace(/\s+/g, " ") ?? "";
-  return normalized ? normalized.slice(0, 160) : fallback;
+  const normalized = value?.trim().replace(/\s+/g, " ") ?? "";
+  return normalized ? Array.from(normalized).slice(0, 160).join("") : fallback;
 }
 
 export function studioAccountInitial(
   value: string | null | undefined,
   fallback = "D",
 ): string {
   const displayName = studioAccountDisplayName(value, "");
-  return Array.from(displayName)[0]?.toLocaleUpperCase() ?? fallback;
+  return Array.from(displayName)[0]?.toLocaleUpperCase("en-US") ?? fallback;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/studio-account-profile.ts` around lines 5 - 14, Update
studioAccountDisplayName and studioAccountInitial to make normalization
deterministic: truncate the normalized display name by Unicode code points
without splitting surrogate pairs, and uppercase the selected initial with an
explicit locale-independent locale. Preserve the existing fallback behavior for
empty or missing display names.
app/api/auth/google/callback/route.ts (1)

60-62: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid the dynamic re-import of @/lib/access-tier.

createStudioAccountCookie, resolveAccountCookieSecret, and STUDIO_ACCOUNT_COOKIE are already statically imported from @/lib/access-tier at the top of the file. Add readStudioAccountCookie to that same static import instead of dynamically re-importing the module here.

♻️ Proposed fix
 import {
   createStudioAccountCookie,
+  readStudioAccountCookie,
   resolveAccountCookieSecret,
   STUDIO_ACCOUNT_COOKIE,
 } from "`@/lib/access-tier`";
@@
-    const account = await import("`@/lib/access-tier`").then(({ readStudioAccountCookie }) =>
-      readStudioAccountCookie(accountCookie, signingSecret),
-    );
+    const account = readStudioAccountCookie(accountCookie, signingSecret);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/auth/google/callback/route.ts` around lines 60 - 62, Update the
existing static import from "`@/lib/access-tier`" to include
readStudioAccountCookie, then call it directly in the account initialization
instead of dynamically importing the module. Preserve the existing arguments and
behavior.
db/studio-account-state.ts (1)

180-189: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Add a repair path for an unparsable stored record.

readStored throws StudioAccountStateUnavailableError when parseState rejects the stored JSON. mutateState reads before every write. One malformed record therefore blocks read, save, and delete for that account forever, and the operator has no recovery route through the API.

Keep the strict read for the account API, but give writes a bounded recovery: copy the unreadable record to a quarantine path, then continue from emptyState(). Record the event so the loss is visible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@db/studio-account-state.ts` around lines 180 - 189, Add bounded recovery in
mutateState for StudioAccountStateUnavailableError from readStored: copy the
unreadable record to a quarantine path, record the recovery event, and continue
mutation from emptyState(). Keep readStored strict for account API reads, and
ensure quarantine or logging failures do not silently overwrite the original
record.
app/api/account/connections/route.ts (1)

28-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move sameOrigin into one shared module.

This function repeats the logic in app/api/agent/plan/route.ts lines 61-75 and the builder variant. The copies already differ: this version rejects a request with no origin header, the plan route accepts it outside production. Divergent copies of a cross-site guard become a security defect over time.

Extract one helper, expose the "missing origin" behavior as an option, and call it from every route.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/account/connections/route.ts` around lines 28 - 45, Extract the
duplicated sameOrigin logic into a shared helper module, preserving its existing
host, protocol, origin, and sec-fetch-site validation. Add an option controlling
whether a missing Origin header is accepted, then update the connections route,
plan route, and builder variant to call the shared helper with their intended
missing-origin behavior.
lib/studio-account-state.ts (1)

67-79: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Derive the vault key with a KDF, not a single SHA-256 pass.

resolveConnectionVaultKey hashes the configured passphrase one time. The result has no salt and no work factor. DROPS_CONNECTION_VAULT_KEY only needs 32 bytes of text, so an operator can supply a low-entropy passphrase. If a stored record leaks, an offline guess of the passphrase decrypts every remembered credential.

Use hkdfSync with a fixed context string, or scryptSync for passphrase input. Record the derivation version inside EncryptedStudioConnection so existing records stay readable during migration.

🔐 Suggested derivation change
-import {
-  createCipheriv,
-  createDecipheriv,
-  createHash,
-  randomBytes,
-} from "node:crypto";
+import {
+  createCipheriv,
+  createDecipheriv,
+  hkdfSync,
+  randomBytes,
+} from "node:crypto";
@@
-  return createHash("sha256").update(configured, "utf8").digest();
+  return Buffer.from(hkdfSync(
+    "sha256",
+    Buffer.from(configured, "utf8"),
+    Buffer.from("drops-studio-connection-vault:v1", "utf8"),
+    Buffer.from("drops-studio-connection-key", "utf8"),
+    32,
+  ));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/studio-account-state.ts` around lines 67 - 79, Update
resolveConnectionVaultKey to derive the vault key with a password-appropriate
KDF such as scryptSync, or hkdfSync with the specified fixed context, instead of
a single unsalted SHA-256 pass. Add a derivation-version field to
EncryptedStudioConnection and use it when reading records so existing encrypted
records remain decryptable during migration.
tests/studio-account-state.test.mjs (1)

8-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the blob persistence layer.

These tests cover the vault primitives well. The persistence layer in db/studio-account-state.ts has no test, and it holds the riskiest logic: the conditional-write retry loop, the parseState rejection path, the 96 KiB bound, and the pre-v1 envelope fallback in readStudioConnectionSecret.

Every exported function accepts storageOverride, so a fake BlobStorage with get and put can drive these paths without network access. Add cases for a first write with no ETag, a concurrent ETag mismatch followed by a successful retry, and a stored record that fails validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/studio-account-state.test.mjs` around lines 8 - 72, Add tests for the
persistence functions in db/studio-account-state.ts using a fake BlobStorage
supplied through storageOverride. Cover an initial write without an ETag, a
conditional-write ETag mismatch followed by a successful retry, parseState
rejection for invalid stored data, the 96 KiB size limit, and pre-v1 envelope
fallback in readStudioConnectionSecret; verify the expected get/put behavior and
outcomes without network access.
app/api/builder/shared.ts (1)

264-271: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the repeated provider list and the cast with one typed constant.

The supported provider names appear at line 254 and again at line 265, and line 270 casts the value to a union that no check guarantees. A typed constant plus a type guard removes the cast and keeps the two lists in sync.

♻️ Suggested refactor
+const REMEMBERED_PROVIDERS = ["openai", "anthropic", "openrouter", "kimi", "custom"] as const;
+type RememberedProvider = (typeof REMEMBERED_PROVIDERS)[number];
+
+function isRememberedProvider(value: string): value is RememberedProvider {
+  return (REMEMBERED_PROVIDERS as readonly string[]).includes(value);
+}
@@
-  const provider = selection.provider;
-  if (!["openai", "anthropic", "openrouter", "kimi", "custom"].includes(provider)) {
-    return { credentials, selection };
-  }
-  const remembered = await readStudioConnectionSecret(
-    account.identity,
-    provider as "openai" | "anthropic" | "openrouter" | "kimi" | "custom",
-  ).catch(() => null);
+  const provider = selection.provider;
+  if (!isRememberedProvider(provider)) return { credentials, selection };
+  const remembered = await readStudioConnectionSecret(account.identity, provider)
+    .catch(() => null);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/builder/shared.ts` around lines 264 - 271, Define one typed
supported-provider constant near the existing provider definitions and reuse it
for validation in the selection flow. Replace the inline provider list and the
cast passed to readStudioConnectionSecret with a type guard that narrows
provider to the supported-provider union before the call, keeping both checks
synchronized.
components/drops-studio-dialogs.tsx (1)

168-192: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Give the account card an accessible name.

The section element has no accessible name, so assistive technology announces it as a generic region. Add aria-label to describe it.

♿ Proposed change
-            <section className={`studio-account-card ${accountProfile ? "connected" : ""}`}>
+            <section
+              aria-label="Studio account"
+              className={`studio-account-card ${accountProfile ? "connected" : ""}`}
+            >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/drops-studio-dialogs.tsx` around lines 168 - 192, Add an
aria-label to the account card section in the account profile UI so assistive
technology announces a descriptive accessible name. Update the section element
containing the account avatar, profile details, and sign-in/sign-out button;
keep its existing class and content unchanged.
lib/member-project-sync-client.ts (1)

212-234: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Accept an optional expectedRevision to avoid one full listing per delete.

deleteMemberProjectFromCloud calls listMemberProjectsFromCloud() on every invocation. The bulk deletion loop in components/drops-studio.tsx (lines 1982-1994) calls it once per project, so deleting N projects issues N full listings plus N deletes. Let callers pass a known revision and fall back to the listing only when the revision is absent.

♻️ Proposed refactor
 export async function deleteMemberProjectFromCloud(
   projectId: string,
+  expectedRevision?: number,
 ): Promise<void> {
-  const listing = await listMemberProjectsFromCloud();
-  const current = listing.projects.find((project) => project.id === projectId);
-  if (!current) return;
+  let revision = expectedRevision;
+  if (revision === undefined) {
+    const listing = await listMemberProjectsFromCloud();
+    const current = listing.projects.find((project) => project.id === projectId);
+    if (!current) return;
+    revision = current.revision;
+  }
   const response = await fetch("/api/projects", {
     method: "DELETE",
     credentials: "same-origin",
     headers: {
       accept: "application/json",
       "content-type": "application/json",
     },
     body: JSON.stringify({
       id: projectId,
-      expectedRevision: current.revision,
+      expectedRevision: revision,
     }),
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/member-project-sync-client.ts` around lines 212 - 234, Update
deleteMemberProjectFromCloud to accept an optional expectedRevision parameter
and use it directly when provided. Only call listMemberProjectsFromCloud and
resolve the project revision when the parameter is absent, preserving the
existing not-found behavior and DELETE request handling.
components/drops-studio.tsx (1)

1982-1994: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Continue the bulk deletion after a single project fails.

deleteAllProjectsFromLibrary awaits deleteProjectRecord inside the loop. The first rejection exits the loop, so the remaining projects stay in place even when they could be deleted. Collect the failures and report them after the loop.

♻️ Proposed change
-    try {
-      for (const project of [...projects]) await deleteProjectRecord(project);
-      setProjects([]);
-      setToast("All projects deleted.");
-    } catch (error) {
-      setToast(error instanceof Error ? error.message : "Some projects could not be deleted.");
-    }
+    const failures: string[] = [];
+    for (const project of [...projects]) {
+      try {
+        await deleteProjectRecord(project);
+      } catch {
+        failures.push(project.spec.name);
+      }
+    }
+    setToast(
+      failures.length
+        ? `These projects could not be deleted: ${failures.join(", ")}.`
+        : "All projects deleted.",
+    );

deleteProjectRecord already updates the project list after each successful deletion, so the explicit setProjects([]) is no longer required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/drops-studio.tsx` around lines 1982 - 1994, Update
deleteAllProjectsFromLibrary to attempt every project even when an individual
deleteProjectRecord call fails, collecting failed deletions and reporting them
after the loop. Remove the redundant setProjects([]), preserve successful
deletion updates, and show an appropriate success or failure summary in the
toast based on the collected failures.
e2e/fixtures/ui-test.ts (1)

59-63: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why the reset runs only once per session.

The addInitScript callback runs for every document in the context. The new marker makes the storage reset run only on the first document, so state that a test creates during later navigations survives. Add a short comment so a later reader does not remove the guard. Note the behavior change: a second prepareHomePage call in the same context no longer clears storage.

♻️ Proposed change
+    // This init script runs on every document. Reset storage only on the
+    // first one so state created during the flow survives later navigations.
     const seedKey = "drops-studio:e2e-home-seeded"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/fixtures/ui-test.ts` around lines 59 - 63, Add a concise comment
immediately above the seedKey/sessionStorage guard in the addInitScript callback
explaining that the marker limits storage clearing to the first document per
session, preserving state across later navigations and repeated prepareHomePage
calls; do not change the existing reset behavior.
components/project-studio.tsx (2)

2014-2019: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log the discarded error before showing the generic fallback message.

void error; drops the original failure with no logging, so there is no trace to diagnose why the connected model failed. Keep the generic user-facing message (it intentionally avoids leaking provider-specific text), but log the error for diagnostics.

♻️ Proposed fix
-      } catch (error) {
-        void error;
+      } catch (error) {
+        console.error("Project V2 agent edit failed", error);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/project-studio.tsx` around lines 2014 - 2019, Replace the
discarded `error` expression in the failure-handling path with diagnostic
logging before constructing the generic `assistant` fallback message. Preserve
the existing user-facing message and avoid exposing provider-specific error
details in it.

1013-1018: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Remove or consume the autobuild query parameter.

autobuild=1 has no runtime consumer. Project V2 auto-build uses a session-storage key instead. Remove the parameter and update the URL assertions, or implement URL handling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/project-studio.tsx` around lines 1013 - 1018, Update the project
initialization logic near requestedPanel in project-studio.tsx to remove or
consume the obsolete autobuild query parameter; preserve Project V2 auto-build
through its existing session-storage mechanism, and update any related URL
assertions to match the parameter’s removal if it is no longer handled.
components/telegram-channel-wizard.tsx (1)

92-113: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider rendering the phone-entry form immediately while the account check runs in the background.

This effect now always calls /api/telegram/account/status, even when there is no saved accountToken, to support cross-session restore for signed-in accounts. First-time users (no saved token, not signed in) now wait for this round trip before the phone-entry form appears, instead of seeing it immediately as before.

Consider showing the phone phase optimistically by default, and only switching to "connected" if the status check confirms a restored account, so first-time users are not delayed by a network round trip that will return nothing for them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/telegram-channel-wizard.tsx` around lines 92 - 113, Initialize the
Telegram wizard in the phone-entry phase with checking disabled so first-time
users see the form immediately, while keeping the status request in the existing
useEffect for background restoration. When the response confirms a valid
account, continue switching to the connected phase and populating the account
state; preserve the existing fallback cleanup and phone-phase behavior for
failed or missing restoration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/api/account/connections/route.ts`:
- Around line 102-117: Update the connection mutation handlers, including DELETE
and the existing PUT flow, to consume the same request-limit namespace budget
before changing credentials. Add audit records for both save and delete that
contain only the account identity, provider, and outcome; never include
credential data. Preserve existing validation, response, and error behavior.

In `@app/api/account/route.ts`:
- Around line 21-26: Update the unauthenticated response in the `/api/account`
handler to return account: null and vault: { available:
connectionVaultConfigured() }, matching the response contract used by other
branches. In the 503/error branch, reuse the same connectionVaultConfigured()
result for vault.available instead of deriving availability from the
account-state read failure.

In `@app/api/agent/plan/route.ts`:
- Around line 508-514: Update the credential lookup around
readStudioConnectionSecret to catch StudioAccountStateUnavailableError
separately and return a 503 response, rather than converting storage outages to
a missing credential. Preserve null handling for an actually absent remembered
credential so direct-provider validation and the OpenRouter quota path continue
to behave normally.

In `@app/api/auth/google/callback/route.ts`:
- Around line 34-88: Update the successful Google callback flow in GET so an
existing valid Studio account cookie is preserved when available instead of
always creating a new Google-scoped account identity. Reuse the existing account
identity for the Google provider association, or migrate its connections to the
new identity before setting STUDIO_ACCOUNT_COOKIE, ensuring /api/account
continues to expose previously saved connections.

In `@app/api/auth/openrouter/exchange/route.ts`:
- Around line 95-131: Update saveStudioConnection handling in
app/api/auth/openrouter/exchange/route.ts lines 95-131 and
app/api/telegram/account/sign-in/route.ts lines 36-45 to log persistence
failures instead of silently swallowing them. In the OpenRouter route,
distinguish the session key being returned from the credential being
successfully remembered rather than always reporting connected: true; keep
telegramAccountJson(result) behavior unchanged apart from adding failure
logging.

In `@app/api/builder/shared.ts`:
- Around line 280-286: Constrain remembered connection values before merging
them in app/api/builder/shared.ts lines 280-286: limit remembered.model to 192
characters and remembered.endpoint to 2,000 characters. In
app/api/builder/agent/route.ts lines 155-163, revalidate the merged selection
with requestSchema.shape.provider before assigning it to agentRequest.provider.

In `@app/styles/drops-studio.shell.css`:
- Line 32: Update .account-profile-button in app/styles/drops-studio.shell.css
at lines 32-32 to use min-height: 44px, add min-width: 44px, and set font-size:
14px; update .studio-account-card > button in
app/styles/drops-studio.dialogs.css at lines 5-5 with the same three sizing
changes.

In `@app/styles/project-studio.chrome.css`:
- Around line 36-39: Update the .workspace-account-action rule to enforce a
minimum 44px height, ensuring the account button meets the 44×44 CSS-pixel
interactive-target requirement at all viewport widths, including 1440px.

In `@components/drops-studio.tsx`:
- Around line 1265-1276: Update closeConnectionsHub to clear connectionReturnTo
before navigating, then use router.push(connectionReturnTo) for the validated
local destination instead of window.location.assign; preserve the existing
query-cleanup behavior when no return path is set.

In `@components/project-studio.tsx`:
- Around line 601-607: Update the account hydration logic in project-studio.tsx
to set accountProfile whenever payload.profile exists, regardless of whether
profile.name is empty or absent. Preserve the existing profile fields and allow
studioAccountDisplayName and studioAccountInitial to provide the fallback
display values used by the signed-in topbar and Drops Studio dialog flow.
- Around line 3277-3289: Update openConnectionsHub to call commitPendingSpec()
before window.location.assign, matching the behavior of openRuntime,
openPublish, and downloadSource so pending quiet-spec edits are persisted before
navigation.
- Around line 1487-1532: The recordBuilderAgentEvent path should update local
state and persist the conversation through the V1 wrapper only; remove its
persistProject call so conversation-only builder events do not upload projectV2
or advance storageRevision. Keep the existing conversation construction and
setProject behavior unchanged, and avoid invoking saveProjectV2ToCloud from this
callback.

In `@db/studio-account-state.ts`:
- Around line 191-236: Update writeStored and readStored to pass bounded abort
signals to Blob get and put operations, using a timeout and bounded backoff
between mutateState retries. Classify BlobPreconditionFailedError as the only
retryable write conflict; propagate or map token, rate-limit, network, and
timeout failures instead of converting every write error to false. Preserve
cacheControlMaxAge at the SDK-supported minimum of 60 seconds.

---

Outside diff comments:
In `@components/telegram-channel-wizard.tsx`:
- Around line 195-210: Update disconnect so it awaits the DELETE request,
validates the response status, and only clears session and Telegram UI state
after a successful deletion. On request or non-success response failure, surface
an error to the user and preserve the connected state instead of swallowing the
failure.

---

Nitpick comments:
In `@app/api/account/connections/route.ts`:
- Around line 28-45: Extract the duplicated sameOrigin logic into a shared
helper module, preserving its existing host, protocol, origin, and
sec-fetch-site validation. Add an option controlling whether a missing Origin
header is accepted, then update the connections route, plan route, and builder
variant to call the shared helper with their intended missing-origin behavior.

In `@app/api/auth/google/callback/route.ts`:
- Around line 60-62: Update the existing static import from "`@/lib/access-tier`"
to include readStudioAccountCookie, then call it directly in the account
initialization instead of dynamically importing the module. Preserve the
existing arguments and behavior.

In `@app/api/builder/shared.ts`:
- Around line 264-271: Define one typed supported-provider constant near the
existing provider definitions and reuse it for validation in the selection flow.
Replace the inline provider list and the cast passed to
readStudioConnectionSecret with a type guard that narrows provider to the
supported-provider union before the call, keeping both checks synchronized.

In `@components/drops-studio-dialogs.tsx`:
- Around line 168-192: Add an aria-label to the account card section in the
account profile UI so assistive technology announces a descriptive accessible
name. Update the section element containing the account avatar, profile details,
and sign-in/sign-out button; keep its existing class and content unchanged.

In `@components/drops-studio.tsx`:
- Around line 1982-1994: Update deleteAllProjectsFromLibrary to attempt every
project even when an individual deleteProjectRecord call fails, collecting
failed deletions and reporting them after the loop. Remove the redundant
setProjects([]), preserve successful deletion updates, and show an appropriate
success or failure summary in the toast based on the collected failures.

In `@components/project-studio.tsx`:
- Around line 2014-2019: Replace the discarded `error` expression in the
failure-handling path with diagnostic logging before constructing the generic
`assistant` fallback message. Preserve the existing user-facing message and
avoid exposing provider-specific error details in it.
- Around line 1013-1018: Update the project initialization logic near
requestedPanel in project-studio.tsx to remove or consume the obsolete autobuild
query parameter; preserve Project V2 auto-build through its existing
session-storage mechanism, and update any related URL assertions to match the
parameter’s removal if it is no longer handled.

In `@components/telegram-channel-wizard.tsx`:
- Around line 92-113: Initialize the Telegram wizard in the phone-entry phase
with checking disabled so first-time users see the form immediately, while
keeping the status request in the existing useEffect for background restoration.
When the response confirms a valid account, continue switching to the connected
phase and populating the account state; preserve the existing fallback cleanup
and phone-phase behavior for failed or missing restoration.

In `@db/studio-account-state.ts`:
- Around line 180-189: Add bounded recovery in mutateState for
StudioAccountStateUnavailableError from readStored: copy the unreadable record
to a quarantine path, record the recovery event, and continue mutation from
emptyState(). Keep readStored strict for account API reads, and ensure
quarantine or logging failures do not silently overwrite the original record.

In `@e2e/fixtures/ui-test.ts`:
- Around line 59-63: Add a concise comment immediately above the
seedKey/sessionStorage guard in the addInitScript callback explaining that the
marker limits storage clearing to the first document per session, preserving
state across later navigations and repeated prepareHomePage calls; do not change
the existing reset behavior.

In `@lib/member-project-sync-client.ts`:
- Around line 212-234: Update deleteMemberProjectFromCloud to accept an optional
expectedRevision parameter and use it directly when provided. Only call
listMemberProjectsFromCloud and resolve the project revision when the parameter
is absent, preserving the existing not-found behavior and DELETE request
handling.

In `@lib/studio-account-profile.ts`:
- Around line 5-14: Update studioAccountDisplayName and studioAccountInitial to
make normalization deterministic: truncate the normalized display name by
Unicode code points without splitting surrogate pairs, and uppercase the
selected initial with an explicit locale-independent locale. Preserve the
existing fallback behavior for empty or missing display names.

In `@lib/studio-account-state.ts`:
- Around line 67-79: Update resolveConnectionVaultKey to derive the vault key
with a password-appropriate KDF such as scryptSync, or hkdfSync with the
specified fixed context, instead of a single unsalted SHA-256 pass. Add a
derivation-version field to EncryptedStudioConnection and use it when reading
records so existing encrypted records remain decryptable during migration.

In `@tests/studio-account-state.test.mjs`:
- Around line 8-72: Add tests for the persistence functions in
db/studio-account-state.ts using a fake BlobStorage supplied through
storageOverride. Cover an initial write without an ETag, a conditional-write
ETag mismatch followed by a successful retry, parseState rejection for invalid
stored data, the 96 KiB size limit, and pre-v1 envelope fallback in
readStudioConnectionSecret; verify the expected get/put behavior and outcomes
without network access.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 34532287-e049-4365-9cc8-15e6d8fb1e45

📥 Commits

Reviewing files that changed from the base of the PR and between 1daeaee and 3371479.

⛔ Files ignored due to path filters (15)
  • docs/design/current-home-actual.png is excluded by !**/*.png
  • docs/design/current-studio-actual.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/home-builder-chromium-1024-linux-system.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/home-builder-chromium-1024-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/home-builder-chromium-1440-linux-system.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/home-builder-chromium-1440-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/home-builder-chromium-390-linux-system.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/home-builder-chromium-390-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux-system.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux-system.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-390-linux-system.png is excluded by !**/*.png
  • e2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-390-linux.png is excluded by !**/*.png
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (48)
  • .env.example
  • README.md
  • app/api/account/connections/route.ts
  • app/api/account/route.ts
  • app/api/agent/plan/route.ts
  • app/api/auth/google/callback/route.ts
  • app/api/auth/google/start/route.ts
  • app/api/auth/openrouter/exchange/route.ts
  • app/api/builder/agent/route.ts
  • app/api/builder/shared.ts
  • app/api/dropstab/route.ts
  • app/api/telegram/account/create-channel/route.ts
  • app/api/telegram/account/sign-in/route.ts
  • app/api/telegram/account/status/route.ts
  • app/styles/drops-studio.dialogs.css
  • app/styles/drops-studio.responsive.css
  • app/styles/drops-studio.shell.css
  • app/styles/project-studio.chrome.css
  • app/styles/project-studio.responsive.css
  • app/styles/project-studio.runtime.css
  • components/drops-studio-dialogs.tsx
  • components/drops-studio.tsx
  • components/project-studio.tsx
  • components/project-v2-studio-surface.tsx
  • components/project-workspace-dialog.tsx
  • components/telegram-channel-wizard.tsx
  • db/studio-account-state.ts
  • docs/BUILDER_V2.md
  • docs/V2_SECURITY_MODEL.md
  • e2e/contracts/member-access.spec.ts
  • e2e/contracts/member-project-cloud.spec.ts
  • e2e/contracts/v0-studio-flow.spec.ts
  • e2e/fixtures/ui-test.ts
  • e2e/products/free-prompt-game.spec.ts
  • lib/access-tier.ts
  • lib/enterprise-platform/oidc-provider.ts
  • lib/google-oidc.ts
  • lib/member-project-sync-client.ts
  • lib/project-store.ts
  • lib/safe-return-to.ts
  • lib/studio-account-profile.ts
  • lib/studio-account-state.ts
  • lib/telegram-account-request.ts
  • package.json
  • tests/google-oidc.test.mjs
  • tests/project-store.test.mjs
  • tests/studio-account-profile.test.mjs
  • tests/studio-account-state.test.mjs

Comment on lines +102 to +117
export async function DELETE(request: NextRequest) {
const actor = account(request);
if (!actor) return response({ error: "Sign in to change remembered connections." }, 401);
if (!sameOrigin(request)) return response({ error: "Cross-origin connection storage rejected." }, 403);
const provider = request.nextUrl.searchParams.get("provider");
if (!isStudioConnectionProvider(provider)) return response({ error: "Connection provider is invalid." }, 400);
try {
const state = await deleteStudioConnection(actor.identity, provider);
return response({ deleted: true, connections: publicConnectionStatuses(state) });
} catch (error) {
return response(
{ error: error instanceof Error ? error.message : "Connection could not be removed." },
error instanceof StudioAccountStateUnavailableError ? 503 : 400,
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add a rate limit and an audit record to connection mutations.

DELETE removes a persisted credential, so it is a destructive operation. It has no request limit, while PUT has one at lines 71-80. Neither method records an audit event for the credential change.

Apply the same consumeRequestLimit namespace budget to DELETE, and record an audit event for both save and delete. Log the account identity, the provider, and the outcome only. Do not log the credential.

🛡️ Suggested guard for DELETE
   if (!sameOrigin(request)) return response({ error: "Cross-origin connection storage rejected." }, 403);
+  const limit = await consumeRequestLimit({
+    identity: requestIdentity(request),
+    namespace: "account-connection-write",
+    max: 20,
+    windowMs: 10 * 60 * 1_000,
+  }).catch(() => "unavailable" as const);
+  if (limit === "limited") return response({ error: "Too many connection changes. Try again later." }, 429);
+  if (limit === "unavailable" && process.env.NODE_ENV === "production") {
+    return response({ error: "Connection write protection is temporarily unavailable." }, 503);
+  }
   const provider = request.nextUrl.searchParams.get("provider");

As per coding guidelines: "Every external or destructive tool must have explicit approval, timeout, quota, audit record, bounded output, and idempotency behavior."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/account/connections/route.ts` around lines 102 - 117, Update the
connection mutation handlers, including DELETE and the existing PUT flow, to
consume the same request-limit namespace budget before changing credentials. Add
audit records for both save and delete that contain only the account identity,
provider, and outcome; never include credential data. Preserve existing
validation, response, and error behavior.

Source: Coding guidelines

Comment thread app/api/account/route.ts
Comment on lines +21 to +26
if (!account) {
return NextResponse.json(
{ authenticated: false, profile: null, connections: [] },
{ headers: { "cache-control": "no-store" } },
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Locate clients of /api/account and check how they read the vault and account fields.
set -euo pipefail

rg -n --glob '!node_modules' -C4 '"/api/account"|`/api/account`|/api/account\b' \
  --iglob '*.{ts,tsx,js,jsx}'
rg -n --glob '!node_modules' -C2 'vault\??\.available|\.account\??\.provider' \
  --iglob '*.{ts,tsx,js,jsx}'

Repository: svg8bit/drops-studio

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- route candidates ---'
fd -t f 'route\.(ts|tsx|js|jsx)$' app | sort | grep -E 'account|connection|vault' || true
echo '--- route outline ---'
if [ -f app/api/account/route.ts ]; then
  ast-grep outline app/api/account/route.ts
  wc -l app/api/account/route.ts
  cat -n app/api/account/route.ts
fi

echo '--- account/vault symbols ---'
rg -n --glob '!node_modules' --glob '!**/.next/**' \
  'connectionVaultConfigured|DROPS_CONNECTION_VAULT_KEY|vault|authenticated|connections' \
  app components lib src 2>/dev/null | head -n 300 || true

echo '--- endpoint callers and account field access ---'
rg -n --glob '!node_modules' --glob '!**/.next/**' --iglob '*.{ts,tsx,js,jsx}' \
  'api/account|account\.(vault|available|provider)|data\.(vault|account)|\b(vault|account)\??\.(available|provider)' \
  . 2>/dev/null | head -n 400 || true

Repository: svg8bit/drops-studio

Length of output: 24509


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- components/drops-studio.tsx account fetch and types ---'
cat -n components/drops-studio.tsx | sed -n '120,150p;490,540p;680,710p;750,775p;1060,1095p'

echo '--- components/project-studio.tsx account fetch ---'
cat -n components/project-studio.tsx | sed -n '1350,1415p'

echo '--- account state implementation ---'
ast-grep outline lib/studio-account-state.ts
cat -n lib/studio-account-state.ts | sed -n '45,95p;100,180p'

echo '--- all vault.available consumers ---'
rg -n --glob '!node_modules' --glob '!**/.next/**' --iglob '*.{ts,tsx,js,jsx}' \
  'vault\s*(\?|:)?\.\s*available|vault.*available|available.*vault' . 2>/dev/null || true

echo '--- account endpoint tests and mocked shapes ---'
cat -n e2e/contracts/member-access.spec.ts | sed -n '1,75p'
cat -n e2e/contracts/v0-studio-flow.spec.ts | sed -n '1,55p'

Repository: svg8bit/drops-studio

Length of output: 23160


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- readStudioAccountState definition ---'
rg -n -A45 -B8 'export async function readStudioAccountState|function readStudioAccountState' db lib app 2>/dev/null || true

echo '--- deterministic static contract verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

route = Path("app/api/account/route.ts").read_text()
production = "\n".join(
    p.read_text()
    for root in ("app", "components", "lib")
    if Path(root).exists()
    for p in Path(root).rglob("*")
    if p.suffix in {".ts", ".tsx", ".js", ".jsx"}
)

checks = {
    "unauthenticated omits account": bool(
        re.search(r'if\s*\(!account\).*?\{\s*authenticated:\s*false,\s*profile:\s*null,\s*connections:\s*\[\]', route, re.S)
    ),
    "unauthenticated omits vault": bool(
        re.search(r'if\s*\(!account\).*?connections:\s*\[\].*?\}', route, re.S)
        and not re.search(r'if\s*\(!account\).*?vault\s*:', route, re.S)
    ),
    "success derives vault from configuration": "vault: { available: connectionVaultConfigured() }" in route,
    "error hard-codes vault false": "vault: { available: false }" in route,
    "production reads vault.available": bool(
        re.search(r'\b(?:payload|data|account)\??\.vault\??\.available\b', production)
    ),
}
for name, result in checks.items():
    print(f"{name}: {'yes' if result else 'no'}")

for path in sorted(Path("app").rglob("*")) + sorted(Path("components").rglob("*")):
    if path.suffix in {".ts", ".tsx", ".js", ".jsx"}:
        text = path.read_text()
        if re.search(r'\b(?:payload|data|account)\??\.vault\??\.available\b', text):
            print("vault.available consumer:", path)
PY

Repository: svg8bit/drops-studio

Length of output: 4099


Align all /api/account response branches.

Return account: null and vault: { available: connectionVaultConfigured() } for unauthenticated responses. Use the same vault value in the 503 branch. Account-state read failures do not imply that DROPS_CONNECTION_VAULT_KEY is unavailable. Current clients do not read vault.available, so this is an API contract issue, not an observed TypeError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/account/route.ts` around lines 21 - 26, Update the unauthenticated
response in the `/api/account` handler to return account: null and vault: {
available: connectionVaultConfigured() }, matching the response contract used by
other branches. In the 503/error branch, reuse the same
connectionVaultConfigured() result for vault.available instead of deriving
availability from the account-state read failure.

Comment on lines +508 to 514
const rememberedDirect = directProvider && account
? await readStudioConnectionSecret(account.identity, directProvider).catch(() => null)
: null;
const directKey = requestCredential(request, "x-provider-key")
|| rememberedDirect?.credential;
if (directProvider && !directKey) {
return NextResponse.json({ error: `Connect ${directProvider} with an API key before using it.` }, { status: 400 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Separate a storage outage from a missing credential.

readStudioConnectionSecret rejects when account storage is unavailable. .catch(() => null) maps that rejection to "no remembered credential". Two wrong outcomes follow:

  • The direct-provider path returns 400 with "Connect openai with an API key before using it.", although the credential is stored. The user is told to repeat work that is already done.
  • The OpenRouter path at lines 485-489 falls through to the platform quota path and consumes a member allowance instead of using the remembered key.

Catch StudioAccountStateUnavailableError separately and return 503 for that case.

🔧 Suggested handling
-  const rememberedDirect = directProvider && account
-    ? await readStudioConnectionSecret(account.identity, directProvider).catch(() => null)
-    : null;
+  let rememberedDirect: Awaited<ReturnType<typeof readStudioConnectionSecret>> = null;
+  if (directProvider && account) {
+    try {
+      rememberedDirect = await readStudioConnectionSecret(account.identity, directProvider);
+    } catch (error) {
+      if (error instanceof StudioAccountStateUnavailableError) {
+        return NextResponse.json(
+          { error: "Remembered connections are temporarily unavailable. Retry shortly." },
+          { status: 503, headers: { "cache-control": "no-store" } },
+        );
+      }
+      rememberedDirect = null;
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const rememberedDirect = directProvider && account
? await readStudioConnectionSecret(account.identity, directProvider).catch(() => null)
: null;
const directKey = requestCredential(request, "x-provider-key")
|| rememberedDirect?.credential;
if (directProvider && !directKey) {
return NextResponse.json({ error: `Connect ${directProvider} with an API key before using it.` }, { status: 400 });
let rememberedDirect: Awaited<ReturnType<typeof readStudioConnectionSecret>> = null;
if (directProvider && account) {
try {
rememberedDirect = await readStudioConnectionSecret(account.identity, directProvider);
} catch (error) {
if (error instanceof StudioAccountStateUnavailableError) {
return NextResponse.json(
{ error: "Remembered connections are temporarily unavailable. Retry shortly." },
{ status: 503, headers: { "cache-control": "no-store" } },
);
}
rememberedDirect = null;
}
}
const directKey = requestCredential(request, "x-provider-key")
|| rememberedDirect?.credential;
if (directProvider && !directKey) {
return NextResponse.json({ error: `Connect ${directProvider} with an API key before using it.` }, { status: 400 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/agent/plan/route.ts` around lines 508 - 514, Update the credential
lookup around readStudioConnectionSecret to catch
StudioAccountStateUnavailableError separately and return a 503 response, rather
than converting storage outages to a missing credential. Preserve null handling
for an actually absent remembered credential so direct-provider validation and
the OpenRouter quota path continue to behave normally.

Comment thread app/api/auth/google/callback/route.ts
Comment on lines +95 to +131
const existingAccount = resolveStudioAccount(
request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value,
);
const accountCookie = existingAccount
? request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value ?? ""
: createStudioAccountCookie({ provider: "openrouter", subject: payload.user_id }, secret);
const account = existingAccount ?? resolveStudioAccount(accountCookie);
if (account) {
await saveStudioConnection(account.identity, {
provider: "openrouter",
credential: payload.key,
model: "openrouter/free",
label: "OpenRouter OAuth",
}).catch(() => undefined);
}
// The API key is returned once to the initiating browser. For a signed-in
// Studio profile it is also stored only as an AES-GCM encrypted vault entry.
const result = NextResponse.json(
{
key: payload.key,
account: {
provider: "openrouter",
provider: account?.provider ?? "openrouter",
connected: true,
projectSync: memberProjectSyncReadiness(),
},
},
{ headers: { "cache-control": "no-store" } },
);
result.cookies.set(STUDIO_ACCOUNT_COOKIE, accountCookie, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 90,
path: "/",
});
if (!existingAccount) {
result.cookies.set(STUDIO_ACCOUNT_COOKIE, accountCookie, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 90,
path: "/",
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Swallowed connection-persistence failures with no logging or client signal. Both routes call saveStudioConnection(...) and discard failures via .catch(() => undefined), then return a success response regardless of whether the write succeeded. The shared root cause is a fire-and-forget persistence call with no error visibility.

  • app/api/auth/openrouter/exchange/route.ts#L95-L131: log the saveStudioConnection failure at lines 103-109 (for example with console.error), and consider adding a field to the response distinguishing "session key returned" from "credential remembered for next time," since line 117 currently reports connected: true unconditionally.
  • app/api/telegram/account/sign-in/route.ts#L36-L45: log the saveStudioConnection failure at lines 40-44, since telegramAccountJson(result) at line 46 is returned unchanged whether or not persistence succeeded.
📍 Affects 2 files
  • app/api/auth/openrouter/exchange/route.ts#L95-L131 (this comment)
  • app/api/telegram/account/sign-in/route.ts#L36-L45
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/auth/openrouter/exchange/route.ts` around lines 95 - 131, Update
saveStudioConnection handling in app/api/auth/openrouter/exchange/route.ts lines
95-131 and app/api/telegram/account/sign-in/route.ts lines 36-45 to log
persistence failures instead of silently swallowing them. In the OpenRouter
route, distinguish the session key being returned from the credential being
successfully remembered rather than always reporting connected: true; keep
telegramAccountJson(result) behavior unchanged apart from adding failure
logging.

Comment on lines +1265 to +1276
function closeConnectionsHub() {
setConnectionOpen(false);
if (connectionReturnTo) {
window.location.assign(connectionReturnTo);
return;
}
const url = new URL(window.location.href);
for (const key of ["connections", "provider", "flow", "project", "returnTo", "auth"]) {
url.searchParams.delete(key);
}
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear connectionReturnTo after the return navigation.

closeConnectionsHub reads connectionReturnTo but never resets it. The state is set once during initialization from the returnTo query parameter (lines 564-571). After the first close navigates to that path, any later open and close of the Connections Hub navigates to the same stale path again. Reset the state before navigating. Use router.push to keep client-side navigation, because the destination is already validated as a same-origin local path.

🐛 Proposed fix
   function closeConnectionsHub() {
     setConnectionOpen(false);
     if (connectionReturnTo) {
-      window.location.assign(connectionReturnTo);
+      setConnectionReturnTo(null);
+      router.push(connectionReturnTo);
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function closeConnectionsHub() {
setConnectionOpen(false);
if (connectionReturnTo) {
window.location.assign(connectionReturnTo);
return;
}
const url = new URL(window.location.href);
for (const key of ["connections", "provider", "flow", "project", "returnTo", "auth"]) {
url.searchParams.delete(key);
}
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
}
function closeConnectionsHub() {
setConnectionOpen(false);
if (connectionReturnTo) {
setConnectionReturnTo(null);
router.push(connectionReturnTo);
return;
}
const url = new URL(window.location.href);
for (const key of ["connections", "provider", "flow", "project", "returnTo", "auth"]) {
url.searchParams.delete(key);
}
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/drops-studio.tsx` around lines 1265 - 1276, Update
closeConnectionsHub to clear connectionReturnTo before navigating, then use
router.push(connectionReturnTo) for the validated local destination instead of
window.location.assign; preserve the existing query-cleanup behavior when no
return path is set.

Comment on lines +601 to +607
const [accountProfile, setAccountProfile] = useState<{
name: string;
email?: string;
} | null>(null);
const [accountBrain, setAccountBrain] = useState<ProjectProvider | null>(
null,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Confirm the signed-in state is set for accounts with an empty display name.

accountProfile is only set if (payload.profile?.name) is truthy. If the account API returns a profile without a name (or an empty one), accountProfile stays null, and the topbar keeps showing "Sign in" for an already-authenticated user. studioAccountDisplayName already provides a "Drops Studio member" fallback for this exact case, but it is never reached because accountProfile is never set without a name.

Set accountProfile whenever payload.profile exists, and let studioAccountDisplayName/studioAccountInitial handle the empty-name fallback, the same way the account hydration in the Drops Studio dialog flow does.

🐛 Proposed fix
-        if (payload.profile?.name) {
-          setAccountProfile({
-            name: payload.profile.name,
-            ...(payload.profile.email ? { email: payload.profile.email } : {}),
-          });
-        }
+        if (payload.profile) {
+          setAccountProfile({
+            name: payload.profile.name ?? "",
+            ...(payload.profile.email ? { email: payload.profile.email } : {}),
+          });
+        }

Also applies to: 1358-1424

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/project-studio.tsx` around lines 601 - 607, Update the account
hydration logic in project-studio.tsx to set accountProfile whenever
payload.profile exists, regardless of whether profile.name is empty or absent.
Preserve the existing profile fields and allow studioAccountDisplayName and
studioAccountInitial to provide the fallback display values used by the
signed-in topbar and Drops Studio dialog flow.

Comment on lines +1487 to +1532
const recordBuilderAgentEvent = useCallback(
(event: {
phase: "snapshot" | "sandbox" | "verification" | "preview";
status: "active" | "done" | "blocked";
message: string;
}) => {
const current = projectRef.current;
if (!current) return;
const eventId = `builder-${current.id}-${event.phase}`;
const content =
event.status === "active"
? `Working · ${event.message}`
: event.status === "done"
? `Verified · ${event.message}`
: `Paused · ${event.message}`;
const existing = current.conversation ?? [];
if (existing.some((item) => item.id === eventId && item.content === content)) {
return;
}
const conversation = existing.some((item) => item.id === eventId)
? existing.map((item) =>
item.id === eventId
? { ...item, content, createdAt: new Date().toISOString() }
: item,
)
: [
...existing,
{
id: eventId,
role: "assistant" as const,
content,
createdAt: new Date().toISOString(),
},
];
const next: GeneratedProject = {
...current,
conversation,
updatedAt: new Date().toISOString(),
};
projectRef.current = next;
setProject(next);
void persistProject(next, current.updatedAt);
},
[persistProject],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: confirm whether saveProjectV2ToCloud enforces optimistic-concurrency conflicts on stale revisions.
rg -n -A 15 'function saveProjectV2ToCloud|PROJECT_V2_REVISION_CONFLICT' lib

Repository: svg8bit/drops-studio

Length of output: 1444


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- project-studio.tsx relevant symbols ---'
rg -n -A 35 -B 15 'recordBuilderAgentEvent|persistProject|adoptProjectV2|projectV2CloudRevisionRef|cloudSyncAvailableRef' components/project-studio.tsx
printf '%s\n' '--- project-v2-studio-surface.tsx relevant symbols ---'
rg -n -A 30 -B 15 'storageRevision|saveSnapshot|mutateFiles|stopSandbox|restoreCheckpoint|onProjectChange|runBuilder' components/project-v2-studio-surface.tsx
printf '%s\n' '--- V2 sync client ---'
cat -n lib/project-v2-sync-client.ts | sed -n '1,130p'
printf '%s\n' '--- V2 API routes and conflict handling ---'
rg -n -A 35 -B 15 'expectedStorageRevision|PROJECT_V2_REVISION_CONFLICT|storageRevision' app lib components

Repository: svg8bit/drops-studio

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- persistProject exact implementation ---'
sed -n '661,805p' components/project-studio.tsx
printf '%s\n' '--- surface syncSnapshot and saveSnapshot ---'
sed -n '358,447p' components/project-v2-studio-surface.tsx
sed -n '692,735p' components/project-v2-studio-surface.tsx
printf '%s\n' '--- surface runBuilder event sequence ---'
sed -n '543,670p' components/project-v2-studio-surface.tsx
printf '%s\n' '--- all V2 sync API files ---'
git ls-files | rg '(^|/)(route|project-v2|projects).*(ts|tsx|js)$|api/projects/v2'
printf '%s\n' '--- conflict implementation ---'
rg -n -A 45 -B 20 'PROJECT_V2_REVISION_CONFLICT|expectedStorageRevision' --glob '*.ts' --glob '*.tsx' app lib

Repository: svg8bit/drops-studio

Length of output: 26822


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- V2 snapshot write semantics ---'
rg -n -A 80 -B 20 'writeProjectV2Snapshot' db lib app --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- child-owned cloud writes and revision updates ---'
rg -n -A 18 -B 12 'saveProjectV2ToCloud|setStorageRevision|onProjectChange' components/project-v2-studio-surface.tsx
printf '%s\n' '--- parent callback call sites and event count ---'
rg -n -A 12 -B 8 'onAgentEvent|onProjectChange' components/project-v2-studio-surface.tsx

Repository: svg8bit/drops-studio

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- exact revision increment and conflict path ---'
sed -n '452,510p' db/project-v2-snapshots.ts
printf '%s\n' '--- child refresh effect and save closure ---'
sed -n '448,464p' components/project-v2-studio-surface.tsx
sed -n '692,727p' components/project-v2-studio-surface.tsx
printf '%s\n' '--- read-only structural verifier ---'
python3 - <<'PY'
from pathlib import Path
surface = Path("components/project-v2-studio-surface.tsx").read_text()
parent = Path("components/project-studio.tsx").read_text()
db = Path("db/project-v2-snapshots.ts").read_text()

events = surface[surface.index("const runBuilder"):surface.index("useEffect", surface.index("const runBuilder"))]
print("successful runBuilder onAgentEvent calls:", events.count("onAgentEvent?.({"))
print("parent recordBuilderAgentEvent calls persistProject:", parent[parent.index("const recordBuilderAgentEvent"):parent.index("const replaceProject")].count("persistProject(next, current.updatedAt)"))
print("child syncSnapshot effect depends on syncSnapshot:", "}, [syncSnapshot]);" in surface)
print("child saveSnapshot sends storageRevision:", "saveProjectV2ToCloud(next, currentRevision)" in surface)
print("snapshot write increments current revision:", "createEnvelope(currentRevision + 1)" in db)
print("snapshot write rejects mismatched revision:", "currentRevision !== expectedStorageRevision" in db)
PY

Repository: svg8bit/drops-studio

Length of output: 4500


Do not cloud-save Project V2 for conversation-only events.

recordBuilderAgentEvent calls persistProject, which uploads the unchanged projectV2 and advances storageRevision for each builder phase. ProjectV2StudioSurface tracks that revision separately. Its asynchronous refresh can leave saveSnapshot using a stale revision and produce PROJECT_V2_REVISION_CONFLICT. Persist the conversation in the V1 wrapper only, without calling saveProjectV2ToCloud from this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/project-studio.tsx` around lines 1487 - 1532, The
recordBuilderAgentEvent path should update local state and persist the
conversation through the V1 wrapper only; remove its persistProject call so
conversation-only builder events do not upload projectV2 or advance
storageRevision. Keep the existing conversation construction and setProject
behavior unchanged, and avoid invoking saveProjectV2ToCloud from this callback.

Comment on lines +3277 to +3289
const openConnectionsHub = (provider?: string) => {
const returnTo = safeSameOriginReturnPath(
`${window.location.pathname}${window.location.search}`,
window.location.origin,
);
const search = new URLSearchParams({
connections: "1",
returnTo,
});
if (provider) search.set("provider", provider);
window.location.assign(`/?${search.toString()}`);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Flush pending spec edits before navigating to the Connections Hub.

openConnectionsHub navigates away with window.location.assign but, unlike openRuntime, openPublish, and downloadSource, it does not call commitPendingSpec() first. A pending quiet-spec edit (still inside its debounce window) then depends solely on the beforeunload handler's fire-and-forget persistProject call, which is not guaranteed to finish before the browser navigates away.

🐛 Proposed fix
   const openConnectionsHub = (provider?: string) => {
+    commitPendingSpec();
     const returnTo = safeSameOriginReturnPath(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const openConnectionsHub = (provider?: string) => {
const returnTo = safeSameOriginReturnPath(
`${window.location.pathname}${window.location.search}`,
window.location.origin,
);
const search = new URLSearchParams({
connections: "1",
returnTo,
});
if (provider) search.set("provider", provider);
window.location.assign(`/?${search.toString()}`);
};
const openConnectionsHub = (provider?: string) => {
commitPendingSpec();
const returnTo = safeSameOriginReturnPath(
`${window.location.pathname}${window.location.search}`,
window.location.origin,
);
const search = new URLSearchParams({
connections: "1",
returnTo,
});
if (provider) search.set("provider", provider);
window.location.assign(`/?${search.toString()}`);
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/project-studio.tsx` around lines 3277 - 3289, Update
openConnectionsHub to call commitPendingSpec() before window.location.assign,
matching the behavior of openRuntime, openPublish, and downloadSource so pending
quiet-spec edits are persisted before navigation.

Comment on lines +191 to +236
async function writeStored(
identity: string,
stored: StoredState,
state: StudioAccountState,
storage: BlobStorage,
): Promise<boolean> {
const serialized = JSON.stringify(state);
if (new TextEncoder().encode(serialized).byteLength > 96 * 1_024) {
throw new StudioAccountStateUnavailableError("Studio account state exceeded its bounded size.");
}
try {
await storage.put(blobPath(identity), serialized, {
access: "private",
addRandomSuffix: false,
allowOverwrite: Boolean(stored.etag),
contentType: "application/json; charset=utf-8",
cacheControlMaxAge: 60,
...(stored.etag ? { ifMatch: stored.etag } : {}),
});
return true;
} catch {
return false;
}
}

async function mutateState(
identity: string,
mutate: (state: StudioAccountState, now: string) => StudioAccountState,
storageOverride?: BlobStorage,
): Promise<StudioAccountState> {
validIdentity(identity);
if (!storageOverride && localStoreEnabled()) {
const now = new Date().toISOString();
const current = structuredClone(localStore().get(identity) ?? emptyState());
const next = mutate(current, now);
localStore().set(identity, structuredClone(next));
return structuredClone(next);
}
if (!storageOverride && !durableBlobConfigured()) throw new StudioAccountStateUnavailableError();
const storage = await blobClient(storageOverride);
for (let attempt = 0; attempt < 6; attempt += 1) {
const stored = await readStored(identity, storage);
const next = mutate(stored.state, new Date().toISOString());
if (await writeStored(identity, stored, next, storage)) return structuredClone(next);
}
throw new StudioAccountStateUnavailableError("Studio account storage stayed busy after safe retries.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm the `@vercel/blob` surface used by db/studio-account-state.ts.
set -euo pipefail

fd -H -t f 'package.json' -d 2 --exec rg -n '"`@vercel/blob`"' {} \;
fd -t d 'blob' node_modules/@vercel 2>/dev/null || true
rg -n 'BlobPreconditionFailedError|abortSignal|useCache|cacheControlMaxAge|ifMatch' \
  --iglob '**/@vercel/blob/**/*.d.ts' -m 40 || echo "types not vendored in sandbox"

Repository: svg8bit/drops-studio

Length of output: 248


🏁 Script executed:

set -euo pipefail

printf '%s\n' '--- dependency and lockfile entries ---'
rg -n -C 3 '"`@vercel/blob`"|`@vercel/blob`' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null || true

printf '%s\n' '--- installed package files ---'
find node_modules/@vercel/blob -maxdepth 2 -type f -print 2>/dev/null | sort | head -80

printf '%s\n' '--- relevant declarations and implementation symbols ---'
rg -n -C 4 'BlobPreconditionFailedError|abortSignal|ifMatch|cacheControlMaxAge|put\(|get\(' \
  node_modules/@vercel/blob db/studio-account-state.ts 2>/dev/null | head -240

printf '%s\n' '--- account-state implementation and imports ---'
sed -n '1,280p' db/studio-account-state.ts

Repository: svg8bit/drops-studio

Length of output: 28835


🏁 Script executed:

set -euo pipefail

printf '%s\n' '--- public type declarations ---'
rg -n -C 8 'class BlobPreconditionFailedError|BlobPreconditionFailedError|interface PutCommandOptions|type PutCommandOptions|interface GetCommandOptions|type GetCommandOptions|abortSignal|ifMatch|cacheControlMaxAge' \
  node_modules/@vercel/blob/dist/*.d.ts | head -320

printf '%s\n' '--- error definitions and response mapping ---'
rg -n -C 12 'BlobPreconditionFailedError|BlobServiceRateLimited|status === 412|statusCode === 412|412|PRECONDITION|precondition' \
  node_modules/@vercel/blob/dist/*.js node_modules/@vercel/blob/dist/*.cjs | head -360

printf '%s\n' '--- request timeout and option propagation ---'
rg -n -C 10 'function requestApi|const requestApi|requestApi =|abortSignal|signal:' \
  node_modules/@vercel/blob/dist/index.js node_modules/@vercel/blob/dist/client.js | head -360

printf '%s\n' '--- runtime and package constraints ---'
cat node_modules/@vercel/blob/package.json | sed -n '1,180p'

Repository: svg8bit/drops-studio

Length of output: 50376


🏁 Script executed:

set -euo pipefail

printf '%s\n' '--- get and put option declarations ---'
sed -n '1,115p' node_modules/@vercel/blob/dist/index.d.ts
sed -n '1,85p' node_modules/@vercel/blob/dist/create-folder-DAlHaCQ2.d.ts
sed -n '220,245p' node_modules/@vercel/blob/dist/create-folder-DAlHaCQ2.d.ts

printf '%s\n' '--- error response mapping and request behavior ---'
sed -n '642,780p' node_modules/@vercel/blob/dist/chunk-CIIQSN42.js

printf '%s\n' '--- package metadata ---'
node -e 'const p=require("./node_modules/@vercel/blob/package.json"); console.log(JSON.stringify({version:p.version,engines:p.engines,exports:p.exports},null,2))'

Repository: svg8bit/drops-studio

Length of output: 14465


Classify Blob write failures and bound Blob operations.

@vercel/blob 2.6.1 exports BlobPreconditionFailedError, and abortSignal is supported by get and put. Retry only ETag conflicts. Propagate or classify token, rate-limit, network, and timeout errors. Add bounded backoff and a timeout to both Blob operations. Do not set cacheControlMaxAge to 0; the SDK requires at least 60 seconds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@db/studio-account-state.ts` around lines 191 - 236, Update writeStored and
readStored to pass bounded abort signals to Blob get and put operations, using a
timeout and bounded backoff between mutateState retries. Classify
BlobPreconditionFailedError as the only retryable write conflict; propagate or
map token, rate-limit, network, and timeout failures instead of converting every
write error to false. Preserve cacheControlMaxAge at the SDK-supported minimum
of 60 seconds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant